Node.js计划任务解决方案
由于业务需要,项目后端除了要实现接口服务之外,还需要支持计划任务,但是由于目前公司的部署方案整个朝着容器云(Openshift)的方向进行,因此生产环境是基于容器启动的,而基础镜像当中是不包含 crontab 服务的,而且运维同学觉得不应该在基础镜像中加入计划任务服务。在这样的前提下,我就要自己去找计划任务的解决方案了。经过几天的研究想过几种不同的方式,最终选定了一种,下面先从被放弃的思路说起。
基于 Openshift 的内置计划任务机制
Openshift 文档中写明了,它是支持计划任务的,而且用的是和起服务类似的方式,例如:
oc run apollon-pigai-update-tasks --image=appropriate/curl:latest --schedule='* 0 * * *' --restart=Never --labels parent="apollon-pigai" --command -- /bin/bash -c "curl -X POST 'http://a-url-for-trigger-a-job'"
可以看到,这种方式我需要准备一个触发 job 的接口,一个镜像,执行时还会在 Openshift 平台产生 pods,还有一些历史和 log,在测试中发现,一旦设置的不对,会在 Openshift 平台产生大量垃圾信息,而且部署这种计划任务无论用命令行的方式还是配置的方式都不是很好操作,我在测试时还没找到覆盖已经存在的计划任务的方法,只能先删再建。总之不是很适合规范化和自动化。
基于 Jenkins 来管理
这里有两个选择,一个是用公司之前的 Jenkins 服务,另一个是在 Openshift 里重新搭建一个 Jenkins 服务,如果是像上面这样的,定义了专门的 job 接口的情况,其实用 Jenkins 也是可以的,但是不是所有的计划任务都是这样来实施的,有一些是以脚本的方式存在的。另外也不建议为了执行计划任务定义 job 类型的接口。而如果执行脚本的话,就需要 Jenkins 能进入容器执行命令,这样就需要为 Jenkins job 所在环境授权 Openshift 的访问权限,但这是不安全的,所以也不是一个好办法。
基于 pm2 来实现
在寻找解决方案的过程中发现 pm2 start 命令是支持 --cron 这个参数的,格式和 crontab 的规则一样。这样我也可以简单实现一些计划任务的动作,例如:
index.js
console.log(`${new Date()} Hello`);
pm2 命令
pm2 start index.js -n cron --cron "*/2 * * * * *"
pm2 log
2|cron | Fri Sep 22 2017 20:14:28 GMT+0800 (CST) Hello
2|cron | Fri Sep 22 2017 20:14:30 GMT+0800 (CST) Hello
2|cron | Fri Sep 22 2017 20:14:33 GMT+0800 (CST) Hello
2|cron | Fri Sep 22 2017 20:14:34 GMT+0800 (CST) Hello
2|cron | Fri Sep 22 2017 20:14:36 GMT+0800 (CST) Hello
这种思路看起来满足了要求,但是有一定的局限性,那就是不能操作系统命令,比如上面 Openshift 方案里提到的通过 curl 命令来触发的计划任务。另外,这种通过 pm2 命令来管理的方式,如果 job 多了,管理起来也比较复杂,所以还得继续找方法。
基于 Node.js 自己来定制计划任务
这里有两个思路,一个简单,一个相对合理,简单的思路就是利用 setInterval 函数来实现定时,来调用业务方法,复杂的思路则是使用 npm 上一些 cron 管理相关的包来调度业务方法,他们有一个共性就是需要跟随 HTTP 服务实例在一个环境中,这样就要特别注意业务方法的异常捕获,否则很有可能会影响 HTTP 服务的稳定性。所以也不是我的最佳方案。
最终方案
通过对以上种种方案的研究和思考,结合项目和生产环境的实际情况,加上在网上一番搜寻,最终形成了以下方案,如果要简单描述的话,就是基于 pm2 和 node-cron 包实现系统命令调度,cron 作为独立实例运行,job 是一系列独立的系统命令来完成。
cron.js 入口代码
const co = require("co");
const cron = require("node-cron");
const consul = require("./consul");
const shell = {
// 执行单条系统命令
exec: function (cmd, cb) {
console.log(`\n${cmd}`);
const child_process = require("child_process");
const parts = cmd.split(/\s+/g);
const p = child_process.spawn(parts[0], parts.slice(1), {
stdio: "inherit",
});
p.on("exit", function (code) {
let err = null;
if (code) {
err = new Error(
'command "' + cmd + '" exited with wrong status code "' + code + '"'
);
err.code = code;
err.cmd = cmd;
}
if (cb) cb(err);
});
},
// 执行多条系统命令
series: function (cmds, cb) {
const loops = cmds.concat();
const execNext = function () {
shell.exec(loops.shift(), function (err) {
if (err) {
cb(err);
} else {
if (loops.length) execNext();
else cb(null);
}
});
};
execNext();
},
};
co(function* () {
// 拉取配置
let config = yield consul();
// 加载全局变量
global.config = config;
// 注册计划任务
Object.keys(config.cron).map((key) => {
cron.schedule(config.cron[key].schedule, function () {
console.log(`\n${new Date().toLocaleString()} - ${key}`);
shell.series(config.cron[key].scripts, function () {});
});
});
});
// 下面这行的作用是hold住脚本的执行,并且不会占用太多资源,由玉都同学友情赞助
require("net").createServer().listen();
pm2 调度多个实例通过 pm2 的配置文件方式启动
pm2 配置文件:
apps:
- script: ./index.js
name: smart
exec_mode: fork
watch: true
ignore_watch: ['node_modules', 'config.local.js']
env:
envValue: production
port: 8080
- script: ./cron.js
name: cron
exec_mode: fork
watch: true
ignore_watch: ['node_modules', 'config.local.js']
启动命令:
pm2-docker deploy/env/production/pm2.production-test.yml
这样计划任务的声明就可以放到配置文件当中了。
cron 配置
...
cron: {
send_message: {
schedule: '*/10 * * * *',
scripts: ['node src/scripts/send_message.js 10'],
},
update_pigai: {
schedule: '* */1 * * *',
scripts: [
'curl --silent -X POST http://sp.smartstudy.com/api/private/task/update-pigai-tasks',
'curl --silent -X POST http://sp.smartstudy.com/api/private/task/update-pigai-status',
]
}
}
...
将 cron 放到独立的运行实例的好处是不用担心对 HTTP 服务实例产生影响,用 pm2 启动 cron 实例的好处是崩溃了可以重新启动,而且还可以进一步限制资源占用,或者定时重启,cron 实例只调度脚本不执行业务代码的好处是自身占用资源小而且稳定,业务脚本执行完对立即释放资源。
最后,由于线上环境是多实例灵活伸缩的,以上计划任务机制并没有配置在多实例上跑,而是跑在一个单个实例的环境中,以免多个环境同时出发计划任务引发异常。
结束语
经过一番调研,终于找到了适合我们的方案,以上最终方案仍在测试当中,但是应该不会有太大的变化了。希望以上内容能为其他需要计划任务的项目组提供一些参考。